查看原文
其他

说清道明:协程是什么

阿驹 驹说码事 2024-03-29

在程序遇到性能瓶颈的时候,解决方案之一就是采用并发编程技术。

尤其是使用Python这种“执行低效”的编程语言,如何用其实现高效地并发能力被屡屡提起。由于众所周知的原因,在别的语言中常用的多线程并发编程模型在Python里不那么好了。

有群不明就里的闲蛋(闲的蛋疼的人),认为GIL让多线程无法并行执行,他的生命的天空就是灰蒙蒙的。多进程太耗系统资源,多线程又不让好好玩,于是,用Python的同学在稍有编程经验后,就会尝试去弄明白Python菜鸟老鸟都常挂在嘴边的协程是什么。

相关概念

经过与无数不明就里就想用代码干翻全世界的人打交道,发现他们往往不尊重、不重视基本常识。(本文对基本常识的解释也许可能存在有误解的地方,欢迎在公众号后台留言批评指正。)

进程(Process)

在面向进程设计的操作系统中(如Linux 2.4及其之前),进程是程序运行的基本单位;而在面向线程设计的系统中(如Linux 2.6之后),进程只是线程的容器,操作系统调度任务的单位是线程,进程只是用于隔离不同的进程,不同进程间资源不共享,进程内可以资源共享。

操作系统使用进程模型,是为了实现多任务操作系统。例如,让用户可以边听音乐边玩网游。多进程可能是真正的利用多核CPU并行执行,也可能只是分时复用。进程的基本模型和基本行为,都是由操作系统定义的,编程语言只能遵照实现。

有人可能不解,为啥Erlang里的进程,就和操作系统定义的不一样呢?因为不论是进程、线程,最终都是需要用代码来编写和实现的,接口的定义权虽然在操作系统那里,而编程语言却控制着具体实现。Erlang虽然有自己的“进程”模型,但只是Erlang单方面的设计把它叫做“Process”,和操作系统定义的进程是两码事。它实际上是使用操作系统的线程接口实现的,故而实质是线程。

子进程、父进程、主进程

可以从一个进程中启动别的进程,新启动的称为子进程,启动子进程的进程称为父进程,原始最先执行的进程称为主进程。

线程(Thread)

线程是现代操作系统调度任务的基本单位,是进程的组成部分。同一进程下的多个不同线程,可以共享该进程拥有的计算机资源,各个线程之间所拥有的资源一般不共享。

操作系统使用线程模型,是为了提供任务分解为多个子任务并发或并行运行的解决方案,以提高程序执行效率。例如,网游程序既要与服务器传输信息,也要采集用户的键盘鼠标操作,还要渲染游戏画面,都可以拆解为独立子任务分派到不同线程去执行。多线程可以真正利用多核CPU并行执行,亦可分时复用。线程的基本模型和基本行为,也是由操作系统定义的,编程语言只能遵照实现。

有人可能又不解,为啥操作系统说线程可以并行执行,而Python里的线程却不能并行呢?刚提到具体的实现权力在编程语言。Python为了降低麻瓜们编写并发程序的难度,引入了GIL锁的概念,让一个进程内的多线程,只能利用单核,多线程只能分时复用单核轮流执行。这种方式,有好有坏,好处是降低并发编程难度,大大减少并发编程的副作用,坏处是不能利用多核优势。

子线程、父线程、主线程

与进程的父子关系类似。可以从一个线程中启动别的线程,被启动的为子线程,原来的是父线程,原始最先执行的为主线程。

例程(Routine)

语言级别内定义的可被调用的代码段,为了完成某个特定功能而封装在一起的一系列指令。一般的编程语言都用称为函数方法的代码结构来体现。

子例程(Subroutine)

例程中定义的例程。注意,由于例程可以嵌套定义,而且例程也本就是代码拆分设计的子程序,所以,子程序、子例程、例程等概念经常相互混用。在英文技术文章里,routine和subroutine这两个词几乎不作区分,在讨论嵌套和被嵌套这种对比之下才有区分。

并发(Concurrent)


并发描述的是程序的组织结构。指程序要被设计成多个可独立执行的子任务(以利用有限的计算机资源使多个任务可以被实时或近实时执行为目的)。

例如玩网游,需要将客户端服务端数据交换的任务和图形绘制的任务拆分为独立子任务,要让交换一下数据就立即绘制一下图形成为可能,在单核CPU上也可以有较好的游戏体验。如果不拆分,实现这种效果将会变得非常困难。

并行(Parallel)


并行描述的是程序的执行状态。指多个任务同时在多个CPU上执行(以利用富余计算机资源加速完成多个任务为目的)。则称这些任务是并行执行的。这样的程序称为可以并行执行的程序。

如上述玩网游例子,假如在四核机器上执行,和服务器交互的任务单独享用一个核,图形绘制任务再拆分为3个独立子任务分别单独享用一个核,这样不论是与服务器交互,还是图形的计算和渲染都会更快更流畅。

并发提供了一种程序组织结构方式,让问题的解决方案可以并行执行,但并行执行不是必须的

协程(Co-routine)

见名知义,协作式的例程。下面深入解析。

协程是非抢占式的多任务子例程的概括,可以允许有多个入口点在例程中确定的位置来控制程序的暂停与恢复执行。多个入口点是指可以在一个协程内多次使用如yield的关键字,每个yield的位置,都是程序员可以使之让出执行权、暂停、恢复、传递信号、注入执行结果等操作。

高德纳说,例程是协程的特例。暂不深入解析这句话,但我们应该知道了,协程、例程本质上是一回事,不过表现有所差异。例程,就是函数、方法,所以协程在代码的体现,也就是按照函数、方法那样去定义的。

函数在线程内执行,协程当然也在线程内执行,多个协程共享着该线程拥有的资源。由于协程就是函数或方法,在线程运行初始化时,所以,与函数一样,协程的数据结构存放在线程的栈内存中。所以协程的切换,实际上还是函数的调用,是在线程的栈内存中完成的。 进程和线程的切换,要保存的状态很复杂很多,内存占用量也要大很多,涉及的操作系统调度也复杂很多。这就是协程的切换开销比线程和进程都小太多的原因。

注意,协程是可以跨线程调度的,就像一个函数可以放到另一个线程去执行一样。

协程和进程或线程的不同之处。协程要有多少个入口点(即yield多少次)、和接下来调用哪个协程(即yield谁)、各自运行什么任务(占用多少资源)都是程序猿在编程中实现的。这既是优点也是缺点,可见,要用协程写出高质量的并发代码,对程序员的质量有很高的要求。

进程和线程都是由操作系统来调度的,什么时候中断、什么时候返回、接下来调度谁,都是操作系统包办。而且都是抢占式调度,优先级平等的多进程和多线程的执行顺序是不可预测的,而协程的执行顺序是可以被安排的

协程和一般例程(函数/方法)的区别。函数执行是从其第一行开始,一直到返回为止(没有显式return语句的也有返回)。从开始到返回,执行完了,就退出了,生命周期随之结束。函数在各次调用之间,并不会保存之前的执行状态。而协程,有多个入口点,可能会被调度多次,一个协程的暂时退出,是靠调用别的协程实现的。协程的调用,还会保存之前的执行状态,切换到另一个协程后,可以再回来继续往下执行。协程执行的起点,是进入该协程的入口点,不一定是协程定义的第一行代码,该次调用的终点,也不一定是协程的最后一行代码。

协程是用户态线程吗?

你特么在逗我。这种说法,是在无端增加人们理解协程的负担。协程,不是用户态线程,也不是用户(程序员)控制着的线程(如果换成“类似线程的东西”,勉强过得去)。

那再啰嗦一下什么是用户态,以及用户态线程。

操作系统在执行代码的时候,会对代码区别对待,使代码具有不同的权限,意味着不同的代码段可以操控不同的内存区域和各种计算机资源的访问。就是为了实现耳熟能详的“保护模式”。保护模式是为了避免程序员提供给系统执行的代码影响到系统的稳定性和安全性。

所以,操作系统内核的代码几乎都是有特权的,所谓的内核态,Ring0级特权;而程序员编写的应用程序,大多是面向一般用户,几乎都是没有特权的,所谓用户态,Ring3普通权限。所以,一段代码是什么等级,就说这段代码就正处于该等级对应的状态。

然而,程序员也可能需要操作系统底层资源,比如要在系统内植入病毒、木马。当某病毒线程通过中断门、调用门等方式进入内核态破坏操作系统的时候,它当时是内核态线程;如果这个病毒它一会儿又要为你下载日本电影做单纯地文件访问,那它当时就是用户态线程。

明白了吗?别再说协程是用户态线程,这样显得读的书少。

下一文再阐述Python里的协程怎么玩。

注:并发、并行相关概念的描述和图片参考了Rob Pike的《Concurrency is not Parallelism》一文。
协程的概念以及协程与例程的区别,参考了维基百科《Coroutine》词条。

*END*
这里是 驹说码事分享程序猿的码路历程
感谢您的关注

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存